Skip to content

feat(mcp): expose CRUD API to MCP clients via streamable-HTTP endpoint#2800

Draft
tink-bot wants to merge 8 commits into
mainfrom
feat-mcp
Draft

feat(mcp): expose CRUD API to MCP clients via streamable-HTTP endpoint#2800
tink-bot wants to merge 8 commits into
mainfrom
feat-mcp

Conversation

@tink-bot
Copy link
Copy Markdown
Collaborator

@tink-bot tink-bot commented May 26, 2026

Adds a Model Context Protocol server at POST/GET/DELETE /api/v1/mcp so AI clients (Claude Desktop, Cursor, etc.) can drive Vikunja through its existing CRUD API.

Built on github.com/modelcontextprotocol/go-sdk v1.6.1. Tools dispatch through the existing handler.Do* functions, so permission checks, transactions, and events stay on the existing code path. Three-layer auth: mcp:access token scope gates the endpoint, per-resource token scopes gate each tool, and model Can* methods do user-level authorization.

v1 surface: projects, tasks, labels, teams, task_comments (config-gated), task_assignees. Token-only (no JWT) — MCP clients paste a URL + API token.

closes #2499.

kolaente added 7 commits May 26, 2026 22:58
Adds the mcp scope group with a single access permission so it shows up
in GET /api/v1/routes (and therefore in the frontend token form).
Adds APIToken.HasMCPAccess() mirroring the caldav/feeds helpers.

The MCP endpoint will use POST, GET, and DELETE on the same path for the
streamable-HTTP transport, which CanDoAPIRoute's exact (method, path)
match cannot gate. The token middleware therefore skips the route check
for /api/v1/mcp and any sub-path; the actual authorization is delegated
to an inline HasMCPAccess() call in the MCP handler (added in the next
task).

Fixtures gain two MCP tokens for user 1: one mcp-only and one with
mcp:access plus projects read scopes for the per-tool scope filter tests.
Mount /api/v1/mcp (and /api/v1/mcp/*) inside the authenticated route
group. Reject JWT-authed requests with 401 (token-only policy), reject
API tokens without the mcp:access scope with 403, and propagate the
authed *user.User + *models.APIToken to r.Context() via typed keys so
downstream tool handlers can pull them out without depending on Echo.

The MCP protocol — JSON-RPC framing, Mcp-Session-Id management, SSE
streaming — is delegated to github.com/modelcontextprotocol/go-sdk
v1.6.1. tools/list returns {"tools": []} since no tools are registered
yet.
Define the Op bitmask, the Resource struct, the package-level Register
function, and the Dispatch entry point that future tasks will use to
expose CRUD resources over MCP. No resources are registered yet.

Op carries the CRUD-op identity, knows its api-token permission string
(matching apiTokenRoutes exactly), and knows its tool-name suffix.
Resource.Inputs maps each enabled op to a pointer-to-zero of the wrapper
type the dispatcher will allocate and unmarshal into. Register validates
the resource shape and populates a tool-name lookup table so the
dispatcher never has to string-parse names like task_comments_read_all.

Dispatch threads the user from ctx, allocates a fresh wrapper, unmarshals
arguments, asks the wrapper to copy itself onto a fresh model via the
inputAdapter seam (which Task 4 will populate with real implementations),
and forwards to the corresponding handler.Do* function. The Do* calls go
through a swappable crudFuncs struct so the unit tests can verify
dispatch routing without standing up the database.
Wires the projects resource into the MCP server end-to-end. The five
project tools (create, read_one, read_all, update, delete) are now
visible in tools/list and dispatch through handler.Do* like the REST
layer.

- Add ProjectCreateInput / ProjectUpdateInput in inputs.go with
  jsonschema tags covering only the writable fields the model honours
  (title, description, identifier, hex_color, parent_project_id,
  position, is_archived, is_favorite); computed fields like Owner and
  MaxPermission are intentionally absent so the SDK-reflected schema
  stays narrow.
- Add resources.go with a sync.Once-guarded RegisterResources(), and an
  installTools helper that registers tools per (resource, op) on the
  *mcp.Server via a generic addTool[In inputAdapter] helper. The
  handler maps domain failures (permission denials, missing rows,
  validation) to IsError tool results per the SDK convention.
- Add DispatchTyped in dispatcher.go so the AddTool handler can hand a
  pre-unmarshalled wrapper to the dispatcher without a JSON
  round-trip. The existing Dispatch (raw JSON path) delegates to a
  shared dispatchPrepared.
- Wire RegisterResources() + installTools() into newServer() so each
  new MCP session inherits the static tool set.
- Add fixture token 11 (mcp:access + projects:*) for the full-scope
  integration tests; bump TestAPIToken_ReadAll's expected count.
- Refresh TestMCP_ToolsListEmpty into
  TestMCP_ToolsListReturnsRegisteredResources, asserting the five
  projects_* tools are present (Task 6 will introduce scope-based
  filtering of this list).
- Add pkg/webtests/mcp_projects_test.go covering tools/list,
  create/read_one/read_all/update/delete happy paths, schema-validation
  failure on missing required title, permission denial on a forbidden
  project, and nonexistent-id lookup.
Filter MCP tool visibility and invocation by the requesting API token's
(group, permission) scopes. tools/list now returns only the tools the
token's APIPermissions authorise; tools/call additionally re-checks the
scope in the dispatcher as defence-in-depth, so a session created with
one token cannot be reused to invoke tools that token never had access to.

The per-session filter runs at session-init via the StreamableHTTPHandler
getServer factory (which the SDK calls once per session, before caching
the *mcp.Server). The dispatcher check runs on every tools/call and
returns ErrScopeDenied, which the AddTool wrapper renders as an IsError
tool result.
Registers tasks, labels, teams, task_comments and task_assignees through
the MCP tool surface, completing the v1 resource list from the plan:

  * tasks    : create / read_one / update / delete (read_all omitted;
               models.Task.ReadAll is a stub — TaskCollection is OOS)
  * labels   : full CRUD
  * teams    : full CRUD
  * tasks_comments  : full CRUD, install-time gated on
                      config.ServiceEnableTaskComments
  * tasks_assignees : create / read_all / delete only (REST exposes no
                      read_one or update)

Per-resource input wrappers carry the path-param fields (task_id,
user_id) explicitly so MCP callers can provide them as JSON args.
installToolsForToken fans out to one installer per resource; the
generics-bound addTool keeps per-(resource, op) call sites at compile
time. The api_tokens.yml fixture extends token 11 to cover the new
scopes; token count stays at 5 for user 1 so existing token-listing
tests are unaffected.

Integration tests per resource cover tools/list visibility, at least
one successful create or read_all, and a permission denial scenario.
@github-actions github-actions Bot added area/api REST API surface, endpoint design, request/response shapes area/api-tokens Personal API tokens, token scopes, bot/service accounts area/permissions Sharing, link sharing, roles, access control, assignee roles labels May 26, 2026
@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 26, 2026

Preview Deployment

Preview deployments for this PR are available at:

URL Tag Commit
https://pr-2800.preview.vikunja.dev ghcr.io/go-vikunja/vikunja:pr-2800 latest
https://sha-94562235564e69cb20fe6196f690d6763d4daee8.preview.vikunja.dev ghcr.io/go-vikunja/vikunja:sha-94562235564e69cb20fe6196f690d6763d4daee8 9456223
https://sha-b8cabcd825eec61ac6871ec35628cf95f98b7f6d.preview.vikunja.dev ghcr.io/go-vikunja/vikunja:sha-b8cabcd825eec61ac6871ec35628cf95f98b7f6d b8cabcd

The preview environment will start automatically on first visit. Subsequent pushes to this PR will update the pr-2800 image — the preview picks up the new version on restart. The per-commit URLs point to a specific version and will not change.

Run locally with Docker
docker pull ghcr.io/go-vikunja/vikunja:pr-2800
docker run -p 3456:3456 ghcr.io/go-vikunja/vikunja:pr-2800

Last updated for commit 9456223

@kolaente
Copy link
Copy Markdown
Member

auggie review

@augmentcode
Copy link
Copy Markdown

augmentcode Bot commented May 27, 2026

🤖 Augment PR Summary

Summary: This PR adds an MCP (Model Context Protocol) server endpoint at /api/v1/mcp so MCP clients can drive Vikunja via its existing CRUD handlers.

Changes:

  • Introduced a new pkg/modules/mcp package implementing streamable-HTTP MCP using github.com/modelcontextprotocol/go-sdk.
  • Added a registry/dispatcher layer that maps MCP tool calls to existing handler.Do* CRUD flows while preserving permission checks, transactions, and events.
  • Implemented per-request context propagation (authenticated user + API token) for downstream tool handlers.
  • Added a three-layer authorization model: mcp:access gates the endpoint, per-resource token scopes gate tools, and model Can* methods enforce user-level permissions.
  • Updated API token routing metadata to register the new mcp.access scope and added APIToken.HasMCPAccess().
  • Adjusted token middleware to skip the route/method matcher for MCP paths and enforce MCP access inside the MCP handler.
  • Registered MCP routes (/mcp and subpaths) in the main API router.
  • Added fixtures and extensive web/unit tests covering tools/list filtering, scope denial, CRUD calls, and config-gated task comments.
  • Updated Go dependencies (jwt, oauth2) and added JSON schema generation support for tool inputs.

🤖 Was this summary useful? React with 👍 or 👎

Copy link
Copy Markdown

@augmentcode augmentcode Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review completed. 2 suggestions posted.

Fix All in Augment

Comment augment review to trigger a new review at any time.

return nil, errors.New("mcp: nil input prototype")
}
t := reflect.TypeOf(proto)
if t.Kind() != reflect.Pointer {
Copy link
Copy Markdown

@augmentcode augmentcode Bot May 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In pkg/modules/mcp/dispatcher.go:232, the pointer-kind check uses reflect.Pointer, but the standard reflect.Kind constant is reflect.Ptr, so this is likely to not compile or to mis-detect pointer inputs. Other locations where this applies: pkg/modules/mcp/inputs.go:127, pkg/modules/mcp/inputs.go:181, pkg/modules/mcp/inputs.go:190, pkg/modules/mcp/inputs.go:219, pkg/modules/mcp/inputs.go:238.

Severity: high

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reflect.Pointer is the canonical name for the pointer kind in reflect since Go 1.18 — reflect.Ptr is an older alias kept for backward compatibility, and both compare identically. The code compiles cleanly and the full mage test:web / mage test:feature suites pass against this branch, so no change needed on this thread.

Comment thread pkg/modules/mcp/inputs.go Outdated
}
srcVal = srcVal.Elem()
}
if srcVal.IsZero() {
Copy link
Copy Markdown

@augmentcode augmentcode Bot May 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In pkg/modules/mcp/inputs.go:225, skipping all IsZero() values means MCP update wrappers can’t apply explicit false/0 updates (e.g. tasks_update cannot set done=false, and numeric fields can’t be set back to 0). This likely makes MCP update semantics diverge from the REST API for common “unset/clear” operations.

Severity: high

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — addressed in b0bd8ab. copyByJSONTag now distinguishes value-typed sources (still skipped on IsZero for partial-update semantics) from pointer-typed sources (always propagated when non-nil, even on the zero pointee). TaskUpdateInput and ProjectUpdateInput switched their bool / numeric fields to pointers (*bool, *int64, etc.), so callers can now explicitly send done: false, is_archived: false, priority: 0, etc. Integration tests TestMCP_Tasks_UpdateClearsDone and TestMCP_Projects_UpdateClearsArchived cover the regression end-to-end.

copyByJSONTag previously skipped any IsZero value, which made it
impossible for tasks_update / projects_update to flip done from true
to false, reset priority/percent_done to 0, or unarchive a project.

A non-nil pointer src is now the unambiguous "caller supplied this"
signal: dereferenced values are written through even when zero, while
value-typed src fields keep the partial-update semantics. The
affected wrapper fields (Done, IsArchived, IsFavorite, Priority,
PercentDone, RepeatAfter, RepeatMode, BucketID,
CoverImageAttachmentID, ParentProjectID, Position) move to pointer
types so the JSON Schema still marks them optional.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/api REST API surface, endpoint design, request/response shapes area/api-tokens Personal API tokens, token scopes, bot/service accounts area/permissions Sharing, link sharing, roles, access control, assignee roles

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants